Перед нами стоит задача, с которой могут столкнуться различные фотохостинги для профессиональных фотографов. Пользователи таких платформ размещают свои фотографии и сопровождают их подробными описаниями: указывают место съемки, модель камеры и другие детали. Существуют такие сервисы, где описания могут добавлять не только авторы фотографий, но и другие пользователи. Пример описания: "A hiker poses for a picture in front of stunning mountains and clouds." Кейс подобной компании мы и постараемся решить.
Мы планируем провести эксперимент по созданию поиска референсных фотографий для фотографов. Идея заключается в том, что пользователь вводит описание нужной сцены, например, "A man is crossing a mountain pass on a metal bridge." Сервис должен предоставить несколько фотографий с аналогичной или похожей сценой. Для этого нам нужно реализовать Proof of Concept (PoC) — доказательство концепции, которое покажет, что проект можно осуществить, создав демонстрационную версию поиска изображений по текстовым запросам.
Для демонстрационной версии необходимо выбрать наилучшую модель, которая сможет создать векторное представление изображений и текстов, и на основе этого выдавать оценку от 0 до 1, показывающую степень соответствия текста и изображения. Эта модель будет основой для создания предварительной версии продукта.
Также необходимо учитывать юридические ограничения. В некоторых странах, где могут работать компании, действуют законы, запрещающие без разрешения родителей или законных представителей предоставлять любую информацию, включая тексты, изображения, видео и аудио, содержащие данные о детях. Под ребенком понимается любой человек моложе 16 лет.
Мы строго соблюдаем законы стран, в которых работаем. Поэтому при попытке посмотреть изображения, запрещенные законодательством, будет отображаться уведомление: "This image is unavailable in your country in compliance with local laws." Однако в PoC этот функционал будет недоступен, поэтому важно очистить данные от запрещенного контента. Во время тестирования модели при наличии "вредного" контента должен отображаться соответствующий дисклеймер.
В файле train_dataset.csv находится информация, необходимая для обучения: имя файла изображения, идентификатор описания и текст описания. Для одной картинки может быть доступно до 5 описаний. Идентификатор описания имеет формат <имя файла изображения>#<порядковый номер описания>.
В папке train_images содержатся изображения для тренировки модели.
В файле CrowdAnnotations.tsv — данные по соответствию изображения и описания, полученные с помощью краудсорсинга. Номера колонок и соответствующий тип данных:
В файле ExpertAnnotations.tsv содержатся данные по соответствию изображения и описания, полученные в результате опроса экспертов. Номера колонок и соответствующий тип данных:
3, 4, 5 — оценки трёх экспертов.
Эксперты ставят оценки по шкале от 1 до 4, где 1 — изображение и запрос совершенно не соответствуют друг другу, 2 — запрос содержит элементы описания изображения, но в целом запрос тексту не соответствует, 3 — запрос и текст соответствуют с точностью до некоторых деталей, 4 — запрос и текст соответствуют полностью.
В файле test_queries.csv находится информация, необходимая для тестирования: идентификатор запроса, текст запроса и релевантное изображение. Для одной картинки может быть доступно до 5 описаний. Идентификатор описания имеет формат <имя файла изображения>#<порядковый номер описания>.
В папке test_images содержатся изображения для тестирования модели.
import pandas as pd
import numpy as np
from scipy import stats
from IPython.display import display, HTML
import matplotlib.pyplot as plt
#ResNet-18
import torch.nn as nn
from torchvision.models import resnet18, ResNet18_Weights
from torchvision import transforms
import torch
import transformers as ppb
import zipfile, os, time
from google.colab import drive
from PIL import Image
from tqdm import tqdm
#модуль для корректного разделения на тренировную и валидационную выборки
from sklearn.model_selection import GroupShuffleSplit
from sklearn.preprocessing import StandardScaler
#модули для обучения и теста модели
from sklearn.dummy import DummyRegressor
from sklearn.linear_model import LinearRegression, Ridge, Lasso
#полносвязная нейронка
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.callbacks import EarlyStopping
from sklearn.metrics import mean_squared_error, mean_absolute_error
drive.mount('/content/drive')
PATH = '/content/drive/MyDrive/data/'
SEED=12345
Mounted at /content/drive
#тренировочный датасет
train_dataset = pd.read_csv(PATH + 'train_dataset.csv')
#датасет с оценками из краудфандинга
crowd_annotations = pd.read_csv(
PATH + 'CrowdAnnotations.tsv',
sep='\t',
names=['image', 'query_id', 'percent', 'agree', 'disagree'],
dtype= {'percent': 'float64', 'agree': 'int8', 'disagree': 'int8'}
)
#оценки экспертов
expert_annotations = pd.read_csv(
PATH + 'ExpertAnnotations.tsv',
sep='\t',
names=['image', 'query_id', 'first', 'second', 'third'],
dtype= {'first': 'int8', 'second': 'int8', 'third': 'int8'}
)
#тестовые запросы
test_queries = pd.read_csv(
PATH + 'test_queries.csv',
sep='|',
index_col=[0]
)
Разделим наш столбец query_id на два столбца, в одном будет ссылка на изображение, а в другом - порядковый номер описания. Так мы сможем корректно отслеживать число описаний для одного изображения.
# отсортируем данные по адресу на картинку для удобства
train_dataset = train_dataset.sort_values(by='image', ascending=True)
crowd_annotations = crowd_annotations.sort_values(by='image', ascending=True)
expert_annotations = expert_annotations.sort_values(by='image', ascending=True)
# Разделяем столбец 'query_id' на два новых столбца
train_dataset[['query_id', 'serial_number_query']] = train_dataset['query_id'].str.split('#', expand=True)
crowd_annotations[['query_id', 'serial_number_query']] = crowd_annotations['query_id'].str.split('#', expand=True)
expert_annotations[['query_id', 'serial_number_query']] = expert_annotations['query_id'].str.split('#', expand=True)
test_queries[['query_id', 'serial_number_query']] = test_queries['query_id'].str.split('#', expand=True)
#преобразуем номер описания в целочисленный тип
train_dataset['serial_number_query'] = train_dataset['serial_number_query'].astype('int8')
crowd_annotations['serial_number_query'] = crowd_annotations['serial_number_query'].astype('int8')
expert_annotations['serial_number_query'] = expert_annotations['serial_number_query'].astype('int8')
test_queries['serial_number_query'] = test_queries['serial_number_query'].astype('int8')
# Посмотрим на результат
display(train_dataset.head(), HTML('<hr>'), crowd_annotations.head(), HTML('<hr>'), expert_annotations.head())
display(HTML('<hr>'))
display(train_dataset.info(), crowd_annotations.info(), expert_annotations.info())
| image | query_id | query_text | serial_number_query | |
|---|---|---|---|---|
| 0 | 1056338697_4f7d7ce270.jpg | 2549968784_39bfbe44f9.jpg | A young child is wearing blue goggles and sitt... | 2 |
| 59 | 1056338697_4f7d7ce270.jpg | 434792818_56375e203f.jpg | A man and woman look back at the camera while ... | 2 |
| 44 | 1056338697_4f7d7ce270.jpg | 3545652636_0746537307.jpg | A young boy dressed in a red uniform kicks the... | 2 |
| 38 | 1056338697_4f7d7ce270.jpg | 3360930596_1e75164ce6.jpg | A soccer ball is above the head of a man weari... | 2 |
| 31 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | Chinese market street in the winter time . | 2 |
| image | query_id | percent | agree | disagree | serial_number_query | |
|---|---|---|---|---|---|---|
| 0 | 1056338697_4f7d7ce270.jpg | 1056338697_4f7d7ce270.jpg | 1.0 | 3 | 0 | 2 |
| 27 | 1056338697_4f7d7ce270.jpg | 3110649716_c17e14670e.jpg | 0.0 | 0 | 3 | 2 |
| 28 | 1056338697_4f7d7ce270.jpg | 3143155555_32b6d24f34.jpg | 0.0 | 0 | 3 | 2 |
| 29 | 1056338697_4f7d7ce270.jpg | 3192069971_83c5a90b4c.jpg | 0.0 | 0 | 3 | 2 |
| 30 | 1056338697_4f7d7ce270.jpg | 3275704430_a75828048f.jpg | 0.0 | 0 | 3 | 2 |
| image | query_id | first | second | third | serial_number_query | |
|---|---|---|---|---|---|---|
| 0 | 1056338697_4f7d7ce270.jpg | 2549968784_39bfbe44f9.jpg | 1 | 1 | 1 | 2 |
| 1 | 1056338697_4f7d7ce270.jpg | 2718495608_d8533e3ac5.jpg | 1 | 1 | 2 | 2 |
| 2 | 1056338697_4f7d7ce270.jpg | 3181701312_70a379ab6e.jpg | 1 | 1 | 2 | 2 |
| 3 | 1056338697_4f7d7ce270.jpg | 3207358897_bfa61fa3c6.jpg | 1 | 2 | 2 | 2 |
| 4 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | 1 | 1 | 2 | 2 |
<class 'pandas.core.frame.DataFrame'> Index: 5822 entries, 0 to 3413 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 image 5822 non-null object 1 query_id 5822 non-null object 2 query_text 5822 non-null object 3 serial_number_query 5822 non-null int8 dtypes: int8(1), object(3) memory usage: 187.6+ KB <class 'pandas.core.frame.DataFrame'> Index: 47830 entries, 0 to 47829 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 image 47830 non-null object 1 query_id 47830 non-null object 2 percent 47830 non-null float64 3 agree 47830 non-null int8 4 disagree 47830 non-null int8 5 serial_number_query 47830 non-null int8 dtypes: float64(1), int8(3), object(2) memory usage: 1.6+ MB <class 'pandas.core.frame.DataFrame'> Index: 5822 entries, 0 to 5821 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 image 5822 non-null object 1 query_id 5822 non-null object 2 first 5822 non-null int8 3 second 5822 non-null int8 4 third 5822 non-null int8 5 serial_number_query 5822 non-null int8 dtypes: int8(4), object(2) memory usage: 159.2+ KB
None
None
None
display(
train_dataset['serial_number_query'].value_counts(),
crowd_annotations['serial_number_query'].value_counts(),
expert_annotations['serial_number_query'].value_counts(),
test_queries['serial_number_query'].value_counts()
)
serial_number_query 2 5822 Name: count, dtype: int64
serial_number_query 2 47830 Name: count, dtype: int64
serial_number_query 2 5822 Name: count, dtype: int64
serial_number_query 0 100 1 100 2 100 3 100 4 100 Name: count, dtype: int64
Как мы видим, в тренировочных данных неверная разметка обозначения номера описания, везде содержится 2, хотя этого не может быть. Поэтому мы сгруппируем данные по названию картинки image, а дальше пронумеруем все номера описания. В виду условия, что может быть не более 5 описаний, то удалим лишние.
Сделаем это после того, как удалим описания неподподающими под цензуру региона.
#посмотрим, есть ли в записях пропуски
print('train_dataset')
display(train_dataset[train_dataset.isnull().any(axis=1)])
print('\n')
print('crowd_annotations')
display(crowd_annotations[crowd_annotations.isnull().any(axis=1)])
print('\n')
print('expert_annotations')
display(expert_annotations[expert_annotations.isnull().any(axis=1)])
print('\n')
train_dataset
| image | query_id | query_text | serial_number_query |
|---|
crowd_annotations
| image | query_id | percent | agree | disagree | serial_number_query |
|---|
expert_annotations
| image | query_id | first | second | third | serial_number_query |
|---|
В данных нет ни одного пропуска, значит, продолжим.
plt.figure(figsize=(17, 10))
plt.hist(expert_annotations['first'], hatch='.', color='green', alpha=0.7, label='Первый эксперт')
plt.hist(expert_annotations['second'], hatch='|', color='blue', alpha=0.7, label='Второй эксперт')
plt.hist(expert_annotations['third'], hatch='-', color='red', alpha=0.3, label='Третий эксперт')
plt.title('Распределение оценок среди экспертов', fontsize=20, pad=10, color='brown')
plt.xlabel('Оценка', fontsize=14, labelpad=1)
plt.ylabel('Частота встречаемости', fontsize=14, labelpad=10)
plt.legend()
plt.show()
Как видно из распределений наибольшое число оценок составляют 1, а наименьшее 4.
Также видно, что первый эксперт более критично оценивает сходства. Третий, наоборот - самый лояльный. Второй выставляет наиболее сбалансированные оценки.
plt.figure(figsize=(17, 5))
plt.hist(crowd_annotations['percent'])
plt.title(
'Распределение процентного отношения оценок из краудсорсинга',
fontsize=20, pad=10, color='brown'
)
plt.xlabel('Процент согласных с описанием к фотографии', fontsize=14, labelpad=1)
plt.ylabel('Частота встречаемости', fontsize=14, labelpad=10)
plt.show()
print(f"Число экспертных оценок: {expert_annotations.shape[0]}")
print(f"Число оценокс краудсорсинга: {crowd_annotations.shape[0]}")
Число экспертных оценок: 5822 Число оценокс краудсорсинга: 47830
Как видно из распределений, для фотографий и запросов на порядок больше оценок из краудсорсинга, что дает большой массив дополнительной информации для нас.
Однако как видно из способа оценивания, то эти данные будут сильно вредить модели, потому что данные оценки имели только значение ноль или один и не подразумевали значения между, что сильно уменьшает точность оценки принадлежности картинки к описанию.
А раз оценки экспертов имеют аж 4 категории, это добавляет точности к определению принадлежности. Позже мы создадим агрегированную оценку экспертов, лежащую в диапазоне от 0 до 1, чтобы можно было использовать на выходных слоях нейросетей сигмоидальную функцию.
Поэтому в будущем будем использовать оценки именно от экспертов.
#оценка числа уникальных значений в столбце 'image'
unique_images_train = train_dataset['image'].nunique()
unique_images_test = test_queries['image'].nunique()
#оценка числа уникальных значений в столбце 'query_id'
unique_query_train = train_dataset['query_id'].nunique()
unique_query_test = test_queries['query_id'].nunique()
#оценка числа уникальных значений в столбце 'query_text'
unique_texts_train = train_dataset['query_text'].nunique()
unique_texts_test = test_queries['query_text'].nunique()
print(f"Число уникальных изображений `image`в тренировном датасете: {unique_images_train}")
print(f"Число уникальных изображений `query_id`в тренировном датасете: {unique_query_train}")
print(f"Число уникальных текстов запросов в тренировном датасете:: {unique_texts_train}\n")
print(f"Число уникальных изображений в тестовом датасете: {unique_images_test}")
print(f"Число уникальных изображений `query_id`в тестовом датасете: {unique_query_test}")
print(f"Число уникальных текстов запросов в тестовом датасете:: {unique_texts_test}")
Число уникальных изображений `image`в тренировном датасете: 1000 Число уникальных изображений `query_id`в тренировном датасете: 977 Число уникальных текстов запросов в тренировном датасете:: 977 Число уникальных изображений в тестовом датасете: 100 Число уникальных изображений `query_id`в тестовом датасете: 100 Число уникальных текстов запросов в тестовом датасете:: 500
print('Основная информация о тренировочном датасете')
display(train_dataset.head(), train_dataset.info())
Основная информация о тренировочном датасете <class 'pandas.core.frame.DataFrame'> Index: 5822 entries, 0 to 3413 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 image 5822 non-null object 1 query_id 5822 non-null object 2 query_text 5822 non-null object 3 serial_number_query 5822 non-null int8 dtypes: int8(1), object(3) memory usage: 187.6+ KB
| image | query_id | query_text | serial_number_query | |
|---|---|---|---|---|
| 0 | 1056338697_4f7d7ce270.jpg | 2549968784_39bfbe44f9.jpg | A young child is wearing blue goggles and sitt... | 2 |
| 59 | 1056338697_4f7d7ce270.jpg | 434792818_56375e203f.jpg | A man and woman look back at the camera while ... | 2 |
| 44 | 1056338697_4f7d7ce270.jpg | 3545652636_0746537307.jpg | A young boy dressed in a red uniform kicks the... | 2 |
| 38 | 1056338697_4f7d7ce270.jpg | 3360930596_1e75164ce6.jpg | A soccer ball is above the head of a man weari... | 2 |
| 31 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | Chinese market street in the winter time . | 2 |
None
Как можно заменить, у нас довольно разнородные данные. Для каждой фотографии представлены различные текстовые запросы и фотографии для этих запросов, что даст нам неплохую информацию для обучения моделей.
Также в данных видна запрещенные в нашем регионе слова для запросов, например "young", "boy" их мы удалим позже.
print('Основная информация о тестовом датасете')
display(test_queries.head(), test_queries.info())
Основная информация о тестовом датасете <class 'pandas.core.frame.DataFrame'> Index: 500 entries, 0 to 499 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 query_id 500 non-null object 1 query_text 500 non-null object 2 image 500 non-null object 3 serial_number_query 500 non-null int8 dtypes: int8(1), object(3) memory usage: 16.1+ KB
| query_id | query_text | image | serial_number_query | |
|---|---|---|---|---|
| 0 | 1177994172_10d143cb8d.jpg | Two blonde boys , one in a camouflage shirt an... | 1177994172_10d143cb8d.jpg | 0 |
| 1 | 1177994172_10d143cb8d.jpg | Two boys are squirting water guns at each other . | 1177994172_10d143cb8d.jpg | 1 |
| 2 | 1177994172_10d143cb8d.jpg | Two boys spraying each other with water | 1177994172_10d143cb8d.jpg | 2 |
| 3 | 1177994172_10d143cb8d.jpg | Two children wearing jeans squirt water at eac... | 1177994172_10d143cb8d.jpg | 3 |
| 4 | 1177994172_10d143cb8d.jpg | Two young boys are squirting water at each oth... | 1177994172_10d143cb8d.jpg | 4 |
None
В тестовом датасете видно, что представлены одинаковые фотографии для рекомендации и для запроса, это может говорить о том, что таким образом можно проверить работу нашей модели, проверяя результаты вывода с табличными данными.
Также в тесте из 500 записей только 100 уникальных фотографий, а вот запросов ровно 500, что позволит оценивать нашу модель более качественно.
Взглянем на рандомные фотографии, которые входят в наши датасеты.
random_selected_indexes = train_dataset.sample(n=10).index.values
random_selections = train_dataset.iloc[random_selected_indexes]
# Вызываем функцию evaluate_model для каждого текста из выборки
for idx in range(len(random_selections)):
display(Image.open(PATH + 'train_images/' + random_selections.iloc[idx]['image']).convert('RGB'))
В наших данных содержатся фотографии с запрещенным контентом, позже нам будет необходимо удалить запросы, которые могут вести на данные картинки, чтобы не нарушать действующее законодательство.
Наш датасет содержит экспертные и краудсорсинговые оценки соответствия текста и изображения.
В файле с экспертными мнениями для каждой пары изображение-текст имеются оценки от трёх специалистов. Для решения задачи вы должны эти оценки агрегировать — превратить в одну. Существует несколько способов агрегации оценок, самый простой — голосование большинства: за какую оценку проголосовала большая часть экспертов (в нашем случае 2 или 3), та оценка и ставится как итоговая. Поскольку число экспертов меньше числа классов, может случиться, что каждый эксперт поставит разные оценки, например: 1, 4, 2. В таком случае данную пару изображение-текст можно исключить из датасета.
# удалим записи, где оценки экспертов полностью разняться
expert_annotations = expert_annotations.loc[
(expert_annotations['first'] == expert_annotations['second']) |
(expert_annotations['first'] == expert_annotations['third']) |
(expert_annotations['second'] == expert_annotations['third'])
]
# получим агрегированную оценку с помощью самого популярного балла, выставленного экспертом,
# после вычтем 1 и разделим результат на 3, чтобы итоговая оценка была в диапазоне [0;1]
expert_annotations['aggregated_expert_score'] = (expert_annotations[['first', 'second', 'third']].mode(axis=1) - 1) / 3
# проверим получившиеся изменения
expert_annotations.head(10)
| image | query_id | first | second | third | serial_number_query | aggregated_expert_score | |
|---|---|---|---|---|---|---|---|
| 0 | 1056338697_4f7d7ce270.jpg | 2549968784_39bfbe44f9.jpg | 1 | 1 | 1 | 2 | 0.000000 |
| 1 | 1056338697_4f7d7ce270.jpg | 2718495608_d8533e3ac5.jpg | 1 | 1 | 2 | 2 | 0.000000 |
| 2 | 1056338697_4f7d7ce270.jpg | 3181701312_70a379ab6e.jpg | 1 | 1 | 2 | 2 | 0.000000 |
| 3 | 1056338697_4f7d7ce270.jpg | 3207358897_bfa61fa3c6.jpg | 1 | 2 | 2 | 2 | 0.333333 |
| 4 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | 1 | 1 | 2 | 2 | 0.000000 |
| 5 | 1056338697_4f7d7ce270.jpg | 3360930596_1e75164ce6.jpg | 1 | 1 | 1 | 2 | 0.000000 |
| 6 | 1056338697_4f7d7ce270.jpg | 3545652636_0746537307.jpg | 1 | 1 | 1 | 2 | 0.000000 |
| 7 | 1056338697_4f7d7ce270.jpg | 434792818_56375e203f.jpg | 1 | 1 | 2 | 2 | 0.000000 |
| 15 | 106490881_5a2dd9b7bd.jpg | 493621130_152bdd4e91.jpg | 1 | 1 | 1 | 2 | 0.000000 |
| 14 | 106490881_5a2dd9b7bd.jpg | 317488612_70ac35493b.jpg | 1 | 1 | 1 | 2 | 0.000000 |
expert_annotations.info()
<class 'pandas.core.frame.DataFrame'> Index: 5696 entries, 0 to 5821 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 image 5696 non-null object 1 query_id 5696 non-null object 2 first 5696 non-null int8 3 second 5696 non-null int8 4 third 5696 non-null int8 5 serial_number_query 5696 non-null int8 6 aggregated_expert_score 5696 non-null float64 dtypes: float64(1), int8(4), object(2) memory usage: 200.2+ KB
Вы можете воспользоваться другим методом агрегации оценок или придумать свой.
В файле с краудсорсинговыми оценками информация расположена в таком порядке:
Однако учитывая, что выбор между значениями соответствует и не соответствует на краудсорсинге хуже того, как оценки выставляли эксперты в диапазоне целых чисел от 1 до 4. Плюс ко всему, на краудсорсинге есть фотографии, за которые проголосовало, например, 2 человека, а есть за которые проголосовало 20, поэтому такие данные могут содержать приличное число выбросов. Также стоит отметить, что число оценок с краудсорсинга почти в 2 раза меньше, чем тех же самых оценок экспертов, и при дальнейших объединениях таблиц мы потеряем большую часть данных.
Поэтому добавление в итоговую оценку мнений краудсорсинга с большей вероятностью ухудшит качество данных ввиду перечисленных выше факторов. Значит, будем использовать только оценки экспертов.
В начале соединим наши тренировочные и экспертные данные.
Объединять их будет по трем столбцам: image и query_id, так как именно для таких пар выставлялись оценки по сходству между изображением запроса и исходником; serial_number_query, чтобы не создавались лишние дубликаты столбцов, так как из анализа в прошлом разделе мы увидели, что значения этого столбца все равны 2-м.
В качестве метода объединения выберем INNER JOIN, так как нам важно, чтобы для всех пар из тренировочного датасета были оценки из экспертного датафрейма и были текстовые описания, которых нет в датасете с оценками.
print(f"Исходный размер тренировочного датасета: {train_dataset.shape}")
train = pd.merge(train_dataset, expert_annotations, on=['image', 'query_id', 'serial_number_query'], how='right')
print(f"Размер тренировочного датасета после соедининения с `expert_annotations`: {train.shape}")
display(train.head())
Исходный размер тренировочного датасета: (5822, 4) Размер тренировочного датасета после соедининения с `expert_annotations`: (5696, 8)
| image | query_id | query_text | serial_number_query | first | second | third | aggregated_expert_score | |
|---|---|---|---|---|---|---|---|---|
| 0 | 1056338697_4f7d7ce270.jpg | 2549968784_39bfbe44f9.jpg | A young child is wearing blue goggles and sitt... | 2 | 1 | 1 | 1 | 0.000000 |
| 1 | 1056338697_4f7d7ce270.jpg | 2718495608_d8533e3ac5.jpg | A girl wearing a yellow shirt and sunglasses s... | 2 | 1 | 1 | 2 | 0.000000 |
| 2 | 1056338697_4f7d7ce270.jpg | 3181701312_70a379ab6e.jpg | A man sleeps under a blanket on a city street . | 2 | 1 | 1 | 2 | 0.000000 |
| 3 | 1056338697_4f7d7ce270.jpg | 3207358897_bfa61fa3c6.jpg | A woman plays with long red ribbons in an empt... | 2 | 1 | 2 | 2 | 0.333333 |
| 4 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | Chinese market street in the winter time . | 2 | 1 | 1 | 2 | 0.000000 |
display(train.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 5696 entries, 0 to 5695 Data columns (total 8 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 image 5696 non-null object 1 query_id 5696 non-null object 2 query_text 5696 non-null object 3 serial_number_query 5696 non-null int8 4 first 5696 non-null int8 5 second 5696 non-null int8 6 third 5696 non-null int8 7 aggregated_expert_score 5696 non-null float64 dtypes: float64(1), int8(4), object(3) memory usage: 200.4+ KB
None
#создание итоговой метрики
train['total'] = train['aggregated_expert_score']
#удалим лишние столбцы
train = train.drop(columns=['first','second','third','aggregated_expert_score'])
display(train.head())
| image | query_id | query_text | serial_number_query | total | |
|---|---|---|---|---|---|
| 0 | 1056338697_4f7d7ce270.jpg | 2549968784_39bfbe44f9.jpg | A young child is wearing blue goggles and sitt... | 2 | 0.000000 |
| 1 | 1056338697_4f7d7ce270.jpg | 2718495608_d8533e3ac5.jpg | A girl wearing a yellow shirt and sunglasses s... | 2 | 0.000000 |
| 2 | 1056338697_4f7d7ce270.jpg | 3181701312_70a379ab6e.jpg | A man sleeps under a blanket on a city street . | 2 | 0.000000 |
| 3 | 1056338697_4f7d7ce270.jpg | 3207358897_bfa61fa3c6.jpg | A woman plays with long red ribbons in an empt... | 2 | 0.333333 |
| 4 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | Chinese market street in the winter time . | 2 | 0.000000 |
В некоторых странах, где работает наша компания, действуют ограничения по обработке изображений: поисковым сервисам и сервисам, предоставляющим возможность поиска, запрещено без разрешения родителей или законных представителей предоставлять любую информацию, в том числе, но не исключительно тексты, изображения, видео и аудио, содержащие описание, изображение или запись голоса детей. Ребёнком считается любой человек, не достигший 16 лет.
В нашем сервисе строго следуют законам стран, в которых работают. Поэтому при попытке посмотреть изображения, запрещённые законодательством, вместо картинок показывается дисклеймер:
This image is unavailable in your country in compliance with local laws
Однако у нас в PoC нет возможности воспользоваться данным функционалом. Поэтому все изображения, которые нарушают данный закон, нужно удалить из обучающей выборки.
#создадим список слов (упоминаний детей), при встрече с которыми стоит удалить запись из нашего датасета
bad_words = [
'young', 'girl', 'girls', 'boy', 'boys', 'baby', 'children', 'childrens', 'school',
'childhood', 'child', 'childs', 'teenagers', 'teenager', 'kid', 'kids',
'Young', 'Girl', 'Girls', 'Boy', 'Boys', 'Baby', 'Children', 'Childrens', 'School',
'Childhood', 'Child', 'Childs', 'Teenagers', 'Teenager', 'Kid', 'Kids'
]
display(train.head())
| image | query_id | query_text | serial_number_query | total | |
|---|---|---|---|---|---|
| 0 | 1056338697_4f7d7ce270.jpg | 2549968784_39bfbe44f9.jpg | A young child is wearing blue goggles and sitt... | 2 | 0.000000 |
| 1 | 1056338697_4f7d7ce270.jpg | 2718495608_d8533e3ac5.jpg | A girl wearing a yellow shirt and sunglasses s... | 2 | 0.000000 |
| 2 | 1056338697_4f7d7ce270.jpg | 3181701312_70a379ab6e.jpg | A man sleeps under a blanket on a city street . | 2 | 0.000000 |
| 3 | 1056338697_4f7d7ce270.jpg | 3207358897_bfa61fa3c6.jpg | A woman plays with long red ribbons in an empt... | 2 | 0.333333 |
| 4 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | Chinese market street in the winter time . | 2 | 0.000000 |
#удалим записи со стоп-словами для нашего региона
train = train[~train['query_text'].apply(lambda x: any(word in x.split() for word in bad_words))]
display(train.head(), train.info())
<class 'pandas.core.frame.DataFrame'> Index: 4131 entries, 2 to 5695 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 image 4131 non-null object 1 query_id 4131 non-null object 2 query_text 4131 non-null object 3 serial_number_query 4131 non-null int8 4 total 4131 non-null float64 dtypes: float64(1), int8(1), object(3) memory usage: 165.4+ KB
| image | query_id | query_text | serial_number_query | total | |
|---|---|---|---|---|---|
| 2 | 1056338697_4f7d7ce270.jpg | 3181701312_70a379ab6e.jpg | A man sleeps under a blanket on a city street . | 2 | 0.000000 |
| 3 | 1056338697_4f7d7ce270.jpg | 3207358897_bfa61fa3c6.jpg | A woman plays with long red ribbons in an empt... | 2 | 0.333333 |
| 4 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | Chinese market street in the winter time . | 2 | 0.000000 |
| 5 | 1056338697_4f7d7ce270.jpg | 3360930596_1e75164ce6.jpg | A soccer ball is above the head of a man weari... | 2 | 0.000000 |
| 7 | 1056338697_4f7d7ce270.jpg | 434792818_56375e203f.jpg | A man and woman look back at the camera while ... | 2 | 0.000000 |
None
Проверим есть ли у нас такие записи, для которых число описаний более 5.
Так как у нас не было пронумеровано число описаний, следовательно, сейчас нам необходимо сгруппировать описания по картинкам.
train['serial_number_query'] = train.groupby('image').cumcount()
display(train.head())
print()
display(train['image'].value_counts().sort_values(ascending=False))
<ipython-input-22-0c1bd17ce772>:1: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
train['serial_number_query'] = train.groupby('image').cumcount()
| image | query_id | query_text | serial_number_query | total | |
|---|---|---|---|---|---|
| 2 | 1056338697_4f7d7ce270.jpg | 3181701312_70a379ab6e.jpg | A man sleeps under a blanket on a city street . | 0 | 0.000000 |
| 3 | 1056338697_4f7d7ce270.jpg | 3207358897_bfa61fa3c6.jpg | A woman plays with long red ribbons in an empt... | 1 | 0.333333 |
| 4 | 1056338697_4f7d7ce270.jpg | 3286822339_5535af6b93.jpg | Chinese market street in the winter time . | 2 | 0.000000 |
| 5 | 1056338697_4f7d7ce270.jpg | 3360930596_1e75164ce6.jpg | A soccer ball is above the head of a man weari... | 3 | 0.000000 |
| 7 | 1056338697_4f7d7ce270.jpg | 434792818_56375e203f.jpg | A man and woman look back at the camera while ... | 4 | 0.000000 |
image
3364151356_eecd07a23e.jpg 10
3107513635_fe8a21f148.jpg 9
3123463486_f5b36a3624.jpg 9
279728508_6bd7281f3c.jpg 9
246055693_ccb69ac5c6.jpg 9
..
2718495608_d8533e3ac5.jpg 1
2739331794_4ae78f69a0.jpg 1
3584561689_b6eb24dd70.jpg 1
3385246141_a263d1053e.jpg 1
327415627_6313d32a64.jpg 1
Name: count, Length: 992, dtype: int64
Выделим основные фичи необходимые для обучения моделей и предсказания таргетов.
features = train[['query_text', 'image']]
target = train['total']
print(features.info())
<class 'pandas.core.frame.DataFrame'> Index: 4131 entries, 2 to 5695 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 query_text 4131 non-null object 1 image 4131 non-null object dtypes: object(2) memory usage: 96.8+ KB None
display(features.head(), features.info())
<class 'pandas.core.frame.DataFrame'> Index: 4131 entries, 2 to 5695 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 query_text 4131 non-null object 1 image 4131 non-null object dtypes: object(2) memory usage: 96.8+ KB
| query_text | image | |
|---|---|---|
| 2 | A man sleeps under a blanket on a city street . | 1056338697_4f7d7ce270.jpg |
| 3 | A woman plays with long red ribbons in an empt... | 1056338697_4f7d7ce270.jpg |
| 4 | Chinese market street in the winter time . | 1056338697_4f7d7ce270.jpg |
| 5 | A soccer ball is above the head of a man weari... | 1056338697_4f7d7ce270.jpg |
| 7 | A man and woman look back at the camera while ... | 1056338697_4f7d7ce270.jpg |
None
Перейдём к векторизации изображений.
Самый примитивный способ — прочесть изображение и превратить полученную матрицу в вектор. Такой способ нам не подходит: длина векторов может быть сильно разной, так как размеры изображений разные. Поэтому стоит обратиться к свёрточным сетям: они позволяют "выделить" главные компоненты изображений.
Выберем архитектуру ResNet-18, предварительно натренированную на датасете ImageNet, так как она показывала хорошие значения accuracy при детекции объектов на изображении, согласно официальной документации. Конечно, ResNet-52 согласно той же документации выдает более крутые значения, все же остановимся на данной модели ввиду меньшего выводимого вектора.
Также нам будет необходимо удалить последний полносвязный слой с предсказанием, чтобы нам выводился именно вектор, а не предсказание.
resnet = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
#заморозим веса, тк мы используем сеть только для векторизации картинок
for param in resnet.parameters():
param.requires_grad_(False)
#выполним нормализацию, рекомендованную для ResNet50
norm = transforms.Normalize(
mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]
)
preprocess = transforms.Compose(
[
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
norm
]
)
Downloading: "https://download.pytorch.org/models/resnet18-f37072fd.pth" to /root/.cache/torch/hub/checkpoints/resnet18-f37072fd.pth 100%|██████████| 44.7M/44.7M [00:00<00:00, 140MB/s]
print('Список слоев у нейронки:')
print(list(resnet.children()))
Список слоев у нейронки:
[Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False), BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True), ReLU(inplace=True), MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False), Sequential(
(0): BasicBlock(
(conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
(1): BasicBlock(
(conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
), Sequential(
(0): BasicBlock(
(conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(downsample): Sequential(
(0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
), Sequential(
(0): BasicBlock(
(conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(downsample): Sequential(
(0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
), Sequential(
(0): BasicBlock(
(conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(downsample): Sequential(
(0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(relu): ReLU(inplace=True)
(conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
), AdaptiveAvgPool2d(output_size=(1, 1)), Linear(in_features=512, out_features=1000, bias=True)]
Нам необходимо исключить из данной модели последние два слоя AdaptiveAvgPool2d и Linear, чтобы получить именно эмбэддинги фотографий.
modules = list(resnet.children())[:-1]
resnet = nn.Sequential(*modules)
#переход в режим предсказания
resnet.eval()
print('Переход в режим предсказания успешно выполнен!')
Переход в режим предсказания успешно выполнен!
#проверка устройств всех параметров модели
devices = set(param.device for param in resnet.parameters())
print("Устройства параметров модели:", devices)
if len(devices) == 1:
print("Все параметры модели находятся на:", list(devices)[0])
else:
print("Внимание: параметры модели распределены по разным устройствам!")
Устройства параметров модели: {device(type='cpu')}
Все параметры модели находятся на: cpu
#перемещение модели на подходящее устройство
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
resnet.to(device)
print('Перемещение параметров модели на GPU выполнено!')
Перемещение параметров модели на GPU выполнено!
# Обработка изображений и получение векторов
def embedding_image(image_dir, model, prep, dataset):
all_vectors = []
for img_path in dataset['image']:
full_path = PATH + image_dir + img_path #полный путь к изображению
img = Image.open(full_path).convert('RGB')
image_tensor = prep(img).unsqueeze(0)
image_tensor = image_tensor.to(device) #перемещаем изображение на устройство модели
with torch.no_grad(): # вычисления без градиентов
output_vector = model(image_tensor).flatten().cpu().numpy()
all_vectors.append(output_vector)
# Преобразование списка векторов в массив NumPy
return np.array(all_vectors)
train_image_vectors = embedding_image(
'train_images/', resnet, preprocess, features
)
display(train_image_vectors[:4])
print()
print(f"Число записей в тренировочном датасете: {features.shape[0]}")
print(f"В получившемся списке векторов: {train_image_vectors.shape[0]}")
print(f"Длина векторов текста: {train_image_vectors.shape[1]}")
array([[0.6939423 , 3.0318372 , 2.9169343 , ..., 0.0850069 , 1.0567007 ,
0.09815677],
[0.6939423 , 3.0318372 , 2.9169343 , ..., 0.0850069 , 1.0567007 ,
0.09815677],
[0.6939423 , 3.0318372 , 2.9169343 , ..., 0.0850069 , 1.0567007 ,
0.09815677],
[0.6939423 , 3.0318372 , 2.9169343 , ..., 0.0850069 , 1.0567007 ,
0.09815677]], dtype=float32)
Число записей в тренировочном датасете: 4131 В получившемся списке векторов: 4131 Длина векторов текста: 512
Проверим правильно ли произошла векторизация изображения сравнив между собой вектора для одинаковых и разных изображений.
print(train_image_vectors[0][130:140])
print(train_image_vectors[1][130:140])
print(train_image_vectors[2][130:140])
[1.3492883 0.62957984 1.1214896 1.2680793 0.33387035 0.46333134 0.12342761 0.13693549 0.32819766 0.38855216] [1.3492883 0.62957984 1.1214896 1.2680793 0.33387035 0.46333134 0.12342761 0.13693549 0.32819766 0.38855216] [1.3492883 0.62957984 1.1214896 1.2680793 0.33387035 0.46333134 0.12342761 0.13693549 0.32819766 0.38855216]
Первая картинка отличается от двух других, которые в свою очередь являются одинаковыми, и это видно по векторам. Значит, векторизация фотографий прошла успешно.
Следующий этап — векторизация текстов. В качестве векторизатора будем использовать DistilBert, так как он выдает меньший вектор по сравнению с стандартной моделью BERT, не сильно уступая в точности. Для нас это важнее, так как при большой разнице в размерах между эмбэдингами текста и картинками модели могут упускать часть признаков.
model_class, tokenizer_class, pretrained_weights = (ppb.DistilBertModel, ppb.DistilBertTokenizer, 'distilbert-base-uncased')
tokenizer = tokenizer_class.from_pretrained(pretrained_weights)
model = model_class.from_pretrained(pretrained_weights)
/usr/local/lib/python3.10/dist-packages/huggingface_hub/utils/_token.py:88: UserWarning: The secret `HF_TOKEN` does not exist in your Colab secrets. To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session. You will be able to reuse this secret in all of your notebooks. Please note that authentication is recommended but still optional to access public models or datasets. warnings.warn(
#токенезация предложений
tokenized = features['query_text'].apply((lambda x: tokenizer.encode(x, add_special_tokens=True)))
#display(tokenized)
print()
print("Максимальная длина вектора:", max(tokenized.apply(len)))
print("Минимальная длина вектора:", min(tokenized.apply(len)))
Максимальная длина вектора: 36 Минимальная длина вектора: 4
После токенизации tokenized представляет собой список предложений - каждое предложение представлено в виде списка токенов. Мы хотим, чтобы BERT обрабатывал все наши примеры сразу одним пакетом. Это ускорит нашу обработку. По этой причине нам нужно привести все списки к одинаковому размеру, чтобы мы могли представить входные данные в виде одного двумерного массива, а не списка списков разной длины.
max_len = 0
for i in tokenized.values:
if len(i) > max_len:
max_len = len(i)
padded = np.array([i + [0]*(max_len-len(i)) for i in tokenized.values])
print(f"Результат: \n{padded}")
print()
print(f"Размер нашего padding-массива: {np.array(padded).shape}")
Результат: [[ 101 1037 2158 ... 0 0 0] [ 101 1037 2450 ... 0 0 0] [ 101 2822 3006 ... 0 0 0] ... [ 101 1037 2158 ... 0 0 0] [ 101 2048 21220 ... 0 0 0] [ 101 1037 2711 ... 0 0 0]] Размер нашего padding-массива: (4131, 36)
Для корректной работы нашей модели создадим маску, чтобы нулевые значения не мешали выводам BERT'a.
attention_mask = np.where(padded != 0, 1, 0)
print(attention_mask)
print()
print(f"Размер маски: {attention_mask.shape}")
[[1 1 1 ... 0 0 0] [1 1 1 ... 0 0 0] [1 1 1 ... 0 0 0] ... [1 1 1 ... 0 0 0] [1 1 1 ... 0 0 0] [1 1 1 ... 0 0 0]] Размер маски: (4131, 36)
#подготовка данных на вход модели
input_ids = torch.tensor(padded)
attention_mask = torch.tensor(attention_mask)
with torch.no_grad():
last_hidden_states = model(input_ids, attention_mask=attention_mask)
#извлечение
text_vectors = last_hidden_states[0][:,0,:].numpy()
print(f"Размер выходного вектора: {text_vectors.shape}")
print()
print(f"Получившиеся эмбэддинги:\n {text_vectors}")
Размер выходного вектора: (4131, 768) Получившиеся эмбэддинги: [[ 0.04261542 -0.24077344 -0.18109433 ... -0.21364668 0.4293283 0.20872879] [-0.09831849 -0.29918435 -0.05440249 ... -0.02151758 0.2308679 0.2127828 ] [-0.40906575 -0.19857952 -0.20296405 ... -0.15438834 0.42174098 0.12961403] ... [-0.15090486 -0.01281469 -0.1450617 ... -0.08681784 0.23639499 0.23343708] [-0.66117233 -0.32593197 -0.19964582 ... -0.30256316 0.41026556 0.15703936] [-0.26644728 -0.2780878 -0.2399873 ... -0.0722068 0.27039662 0.3002583 ]]
Подготовьте данные для обучения: объедините векторы изображений и векторы текстов с целевой переменной.
total_vectors = np.hstack((train_image_vectors, text_vectors))
total_vectors.shape, total_vectors[0][:10]
((4131, 1280),
array([0.6939423 , 3.0318372 , 2.9169343 , 0.95189714, 0.9362956 ,
1.245117 , 0.8265237 , 1.1079423 , 0.16967925, 0.3653833 ],
dtype=float32))
features['image_vectors'] = list(train_image_vectors)
features['text_vectors'] = list(text_vectors)
features['total_vectors'] = list(total_vectors)
display(features.head())
<ipython-input-41-8af5bb78467c>:1: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy features['image_vectors'] = list(train_image_vectors) <ipython-input-41-8af5bb78467c>:2: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy features['text_vectors'] = list(text_vectors)
| query_text | image | image_vectors | text_vectors | total_vectors | |
|---|---|---|---|---|---|
| 2 | A man sleeps under a blanket on a city street . | 1056338697_4f7d7ce270.jpg | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... | [0.042615425, -0.24077344, -0.18109433, -0.147... | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... |
| 3 | A woman plays with long red ribbons in an empt... | 1056338697_4f7d7ce270.jpg | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... | [-0.098318495, -0.29918435, -0.054402493, -0.1... | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... |
| 4 | Chinese market street in the winter time . | 1056338697_4f7d7ce270.jpg | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... | [-0.40906575, -0.19857952, -0.20296405, -0.206... | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... |
| 5 | A soccer ball is above the head of a man weari... | 1056338697_4f7d7ce270.jpg | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... | [-0.19844075, -0.18686627, -0.32220158, -0.100... | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... |
| 7 | A man and woman look back at the camera while ... | 1056338697_4f7d7ce270.jpg | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... | [0.23315167, 0.03707388, 0.036032505, -0.11298... | [0.6939423, 3.0318372, 2.9169343, 0.95189714, ... |
Для обучения разделим датасет на тренировочную и валидационные выборки, исключив попадание изображения и в обучающую, и в тестовую выборки.
Мы будем использовать константную модель, линейную регрессию, Ridge, Lasso и полносвязную нейронную сеть. В данной задаче деревянные модели наврядли покажут приемлимые результаты, так как число признаков большое и модели могут очень быстро переобучиться.
Так как у нас стоит задача предсказывания вероятности в диапазоне [0; 1], где важна устойчивость к аномальным значениям и равномерное воздействие на ошибки, то выберем MAE в качестве метрики качества работы моделей.
features = features.reset_index(drop=True)
target = target.reset_index(drop=True)
print(features.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 4131 entries, 0 to 4130 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 query_text 4131 non-null object 1 image 4131 non-null object 2 image_vectors 4131 non-null object 3 text_vectors 4131 non-null object 4 total_vectors 4131 non-null object dtypes: object(5) memory usage: 161.5+ KB None
gss = GroupShuffleSplit(
n_splits=1, train_size=.8, random_state=SEED
)
train_indices, valid_indices = next(
gss.split(
X=features.drop(columns=['image_vectors', 'text_vectors', 'query_text']),
y=target,
groups=features['image']
)
)
train_features = np.array(features.loc[train_indices]['total_vectors'].tolist())
valid_features = np.array(features.loc[valid_indices]['total_vectors'].tolist())
train_target = np.array(target.loc[train_indices].tolist())
valid_target = np.array(target.loc[valid_indices].tolist())
print(len(train_features), len(train_target))
print(len(valid_features), len(valid_target))
3310 3310 821 821
train_features[0][:5]
array([0.6463402 , 1.7278738 , 0.49390426, 0.6523816 , 0.00369786],
dtype=float32)
standart_scaler = StandardScaler()
train_features = standart_scaler.fit_transform(train_features)
valid_features = standart_scaler.transform(valid_features)
print(train_features[0][:5])
[-0.06079438 0.8393825 -0.53533554 -0.23166932 -1.0179235 ]
dummy_regressor = DummyRegressor(strategy='quantile', quantile=0.6)
dummy_regressor.fit(train_features, train_target)
#получим предсказания
predictions = dummy_regressor.predict(valid_features)
print(predictions[:10])
#вычисление ошибки
error = mean_absolute_error(valid_target, predictions)
print(f"MAE составила: {error:.3f}")
[0.33333333 0.33333333 0.33333333 0.33333333 0.33333333 0.33333333 0.33333333 0.33333333 0.33333333 0.33333333] MAE составила: 0.258
linear_regression = LinearRegression(
n_jobs=-1
).fit(train_features, train_target)
predictions = linear_regression.predict(valid_features)
print(predictions[:5])
print(valid_target[:5])
#получим значение метрики MAE
error = mean_absolute_error(valid_target, predictions)
print(f"MAE составила: {error:.3f}")
[ 0.07525492 -0.27387762 -0.20840025 -0.16048384 -0.17978144] [0. 0.33333333 0. 0. 0. ] MAE составила: 0.564
lasso_regressor = Lasso(alpha=0.1)
lasso_regressor.fit(train_features, train_target)
#получим предсказания
predictions = lasso_regressor.predict(valid_features)
print(predictions[-3:])
print(valid_target[:10])
#вычисление ошибки
error = mean_absolute_error(valid_target, predictions)
print(f"MAE составила: {error:.3f}")
[0.2244713 0.2244713 0.2244713] [0. 0.33333333 0. 0. 0. 0. 0.33333333 0.33333333 0.66666667 0.33333333] MAE составила: 0.240
ridge_regressor = Ridge(alpha=500)
ridge_regressor.fit(train_features, train_target)
#получим предсказания
predictions = ridge_regressor.predict(valid_features)
print(predictions[:10])
print(valid_target[:10])
#вычисление ошибки
error = mean_absolute_error(valid_target, predictions)
print(f"MAE составила: {error:.3f}")
[ 0.21598713 -0.03713787 0.02757215 -0.04110599 0.05224188 0.25618014 0.37995398 0.33326554 0.29543906 0.03098264] [0. 0.33333333 0. 0. 0. 0. 0.33333333 0.33333333 0.66666667 0.33333333] MAE составила: 0.210
train_features.shape[1]
1280
#функция с параметрами модели
def create_model(input_dim):
model = Sequential([
Dense(1280, input_dim=input_dim, activation='relu'),
Dense(512, activation='relu'),
Dense(256, activation='relu'),
Dense(1, activation='sigmoid') #выводит значение от 0 до 1
])
return model
neural_net = create_model(input_dim=train_features.shape[1])
neural_net.compile(
optimizer=Adam(learning_rate=0.0001),
loss='mse',
#metrics=['mae']
)
early_stopping = EarlyStopping(
monitor='val_loss',
patience=10,
verbose=1,
restore_best_weights=True
)
neural_net.fit(
train_features, train_target,
validation_data=(valid_features, valid_target),
epochs=100, batch_size=64,
verbose=2, callbacks=[early_stopping]
)
Epoch 1/100 52/52 - 3s - loss: 0.0788 - val_loss: 0.0645 - 3s/epoch - 62ms/step Epoch 2/100 52/52 - 0s - loss: 0.0439 - val_loss: 0.0605 - 198ms/epoch - 4ms/step Epoch 3/100 52/52 - 0s - loss: 0.0273 - val_loss: 0.0582 - 199ms/epoch - 4ms/step Epoch 4/100 52/52 - 0s - loss: 0.0164 - val_loss: 0.0588 - 191ms/epoch - 4ms/step Epoch 5/100 52/52 - 0s - loss: 0.0106 - val_loss: 0.0587 - 243ms/epoch - 5ms/step Epoch 6/100 52/52 - 0s - loss: 0.0076 - val_loss: 0.0577 - 202ms/epoch - 4ms/step Epoch 7/100 52/52 - 0s - loss: 0.0054 - val_loss: 0.0579 - 211ms/epoch - 4ms/step Epoch 8/100 52/52 - 0s - loss: 0.0041 - val_loss: 0.0587 - 230ms/epoch - 4ms/step Epoch 9/100 52/52 - 0s - loss: 0.0035 - val_loss: 0.0598 - 214ms/epoch - 4ms/step Epoch 10/100 52/52 - 0s - loss: 0.0037 - val_loss: 0.0571 - 237ms/epoch - 5ms/step Epoch 11/100 52/52 - 0s - loss: 0.0035 - val_loss: 0.0608 - 188ms/epoch - 4ms/step Epoch 12/100 52/52 - 0s - loss: 0.0041 - val_loss: 0.0596 - 234ms/epoch - 5ms/step Epoch 13/100 52/52 - 0s - loss: 0.0042 - val_loss: 0.0597 - 200ms/epoch - 4ms/step Epoch 14/100 52/52 - 0s - loss: 0.0040 - val_loss: 0.0594 - 198ms/epoch - 4ms/step Epoch 15/100 52/52 - 0s - loss: 0.0044 - val_loss: 0.0578 - 196ms/epoch - 4ms/step Epoch 16/100 52/52 - 0s - loss: 0.0039 - val_loss: 0.0583 - 189ms/epoch - 4ms/step Epoch 17/100 52/52 - 0s - loss: 0.0036 - val_loss: 0.0581 - 195ms/epoch - 4ms/step Epoch 18/100 52/52 - 0s - loss: 0.0033 - val_loss: 0.0583 - 203ms/epoch - 4ms/step Epoch 19/100 52/52 - 0s - loss: 0.0032 - val_loss: 0.0595 - 212ms/epoch - 4ms/step Epoch 20/100 52/52 - 0s - loss: 0.0032 - val_loss: 0.0565 - 205ms/epoch - 4ms/step Epoch 21/100 52/52 - 0s - loss: 0.0035 - val_loss: 0.0577 - 195ms/epoch - 4ms/step Epoch 22/100 52/52 - 0s - loss: 0.0036 - val_loss: 0.0582 - 187ms/epoch - 4ms/step Epoch 23/100 52/52 - 0s - loss: 0.0034 - val_loss: 0.0572 - 200ms/epoch - 4ms/step Epoch 24/100 52/52 - 0s - loss: 0.0033 - val_loss: 0.0566 - 237ms/epoch - 5ms/step Epoch 25/100 52/52 - 0s - loss: 0.0029 - val_loss: 0.0573 - 191ms/epoch - 4ms/step Epoch 26/100 52/52 - 0s - loss: 0.0029 - val_loss: 0.0567 - 231ms/epoch - 4ms/step Epoch 27/100 52/52 - 0s - loss: 0.0027 - val_loss: 0.0564 - 196ms/epoch - 4ms/step Epoch 28/100 52/52 - 0s - loss: 0.0025 - val_loss: 0.0568 - 190ms/epoch - 4ms/step Epoch 29/100 52/52 - 0s - loss: 0.0026 - val_loss: 0.0555 - 214ms/epoch - 4ms/step Epoch 30/100 52/52 - 0s - loss: 0.0027 - val_loss: 0.0563 - 189ms/epoch - 4ms/step Epoch 31/100 52/52 - 0s - loss: 0.0026 - val_loss: 0.0589 - 204ms/epoch - 4ms/step Epoch 32/100 52/52 - 0s - loss: 0.0027 - val_loss: 0.0572 - 231ms/epoch - 4ms/step Epoch 33/100 52/52 - 0s - loss: 0.0025 - val_loss: 0.0572 - 304ms/epoch - 6ms/step Epoch 34/100 52/52 - 0s - loss: 0.0026 - val_loss: 0.0552 - 297ms/epoch - 6ms/step Epoch 35/100 52/52 - 0s - loss: 0.0028 - val_loss: 0.0580 - 296ms/epoch - 6ms/step Epoch 36/100 52/52 - 0s - loss: 0.0027 - val_loss: 0.0583 - 262ms/epoch - 5ms/step Epoch 37/100 52/52 - 0s - loss: 0.0026 - val_loss: 0.0565 - 318ms/epoch - 6ms/step Epoch 38/100 52/52 - 0s - loss: 0.0022 - val_loss: 0.0582 - 292ms/epoch - 6ms/step Epoch 39/100 52/52 - 0s - loss: 0.0021 - val_loss: 0.0561 - 243ms/epoch - 5ms/step Epoch 40/100 52/52 - 0s - loss: 0.0023 - val_loss: 0.0579 - 279ms/epoch - 5ms/step Epoch 41/100 52/52 - 0s - loss: 0.0024 - val_loss: 0.0562 - 304ms/epoch - 6ms/step Epoch 42/100 52/52 - 0s - loss: 0.0024 - val_loss: 0.0586 - 282ms/epoch - 5ms/step Epoch 43/100 52/52 - 0s - loss: 0.0024 - val_loss: 0.0561 - 248ms/epoch - 5ms/step Epoch 44/100 Restoring model weights from the end of the best epoch: 34. 52/52 - 0s - loss: 0.0022 - val_loss: 0.0572 - 210ms/epoch - 4ms/step Epoch 44: early stopping
<keras.src.callbacks.History at 0x7d401c3dee30>
Мы обучили несколько моделей, основанных на константных значениях, полносвязной нейронки, линейной регрессии и её модификациях.
В результате благодаря тесту на валидационных данных мы выяснили, что именно модель NN показывает наилучшие значения метрики MAE, равные ~0.06, что можно отнести к неплохим результатам.
В ходе обучения была замечена мультиколлениарность признаков, на это указали слишком высокие метрики линейной регрессии, а вот ее модификации, использующие регуляризацию, смогли невилировать эффект, происходящий из-за особенности векторизации текстов и изображений с помощью pytorch. Поэтому, скорее всего, ввиду наименьшей подвержности мультиколлениарности со стороны нейронной сети она и выдала наилучшие результаты при сравнении картинок и описаний.
Настало время протестировать модель. Для этого мы получим эмбеддинги для всех тестовых изображений из папки с тестовыми изображениями, выберем случайные 10 запросов из файла с тестовыми запросами и для каждого запроса выведем наиболее релевантное изображение. Также обязательно сравним визуально качество поиска.
#выгрузим датасет с адресами всех фотографий
test_images = pd.read_csv(
PATH + 'test_images.csv'
)
#получим векторы для тестовых изображений
test_image_vectors = embedding_image(
'test_images/', resnet, preprocess, test_images
)
test_image_vectors.shape
(100, 512)
def test_text_embedding(text, tokenizer, model, max_len):
print(f"\nТекст запроса: `{text}`")
#проверка на наличие запрещенного контента
if any(bad_word in text.split() for bad_word in bad_words):
return 'DANGEROUS'
#токенизация текста
tokenized_text = tokenizer.encode(text, add_special_tokens=True)
#дополнение токенов до max_len
padded_text = tokenized_text + [0] * (max_len - len(tokenized_text))
#создание маски внимания
attention_mask_text = [1 if token != 0 else 0 for token in padded_text]
#преобразование в тензоры PyTorch
input_ids_text = torch.tensor([padded_text])
attention_mask_text = torch.tensor([attention_mask_text])
with torch.no_grad():
last_hidden_states_text = model(input_ids_text, attention_mask=attention_mask_text)
#извлечение вектора для всего текста
text_vector = last_hidden_states_text[0][0,0,:].numpy()
return text_vector
def testing_model(text, images_vectors):
#получаем вектор текста
text_vector = test_text_embedding(text, tokenizer, model, max_len)
if isinstance(text_vector, str) and text_vector == 'DANGEROUS':
print("\nThis image is unavailable in your country in compliance with local laws")
display(HTML("<hr>"))
return None
#инициализация переменных для хранения лучшего результата
best_result = 0
best_match_index = 0
for i in tqdm(range(len(images_vectors))):
#конкатенируем векторы текста и фото
total_vector = np.hstack((images_vectors[i], text_vector))[np.newaxis, :]
#нормализуем входящий в модель вектор
total_vector = standart_scaler.transform(total_vector)
#делаем предсказание
result = neural_net.predict(total_vector, verbose=0)
#обновление лучшего результата и индекса, если текущий результат лучше
if result.squeeze() > best_result:
best_result = result.squeeze()
best_match_index = i
#вывод текста и изображения с наилучшим совпадением
print(text)
display(Image.open(PATH + 'test_images/' + test_images['image'][best_match_index]).convert('RGB'))
print('Best match index:', best_match_index)
print('Best result:', best_result)
display(HTML("<hr>"))
random_selected_indexes = test_queries.sample(n=10).index.values
random_selections = test_queries.iloc[random_selected_indexes]
# Вызываем функцию evaluate_model для каждого текста из выборки
for idx in range(len(random_selections)):
testing_model(random_selections.iloc[idx]['query_text'], test_image_vectors)
Текст запроса: `Man wearing hat and t-shirt with " Genetic Freak " sleeping on public transportation .`
100%|██████████| 100/100 [00:32<00:00, 3.04it/s]
Man wearing hat and t-shirt with " Genetic Freak " sleeping on public transportation .
Best match index: 56 Best result: 0.20039892
Текст запроса: `Four people are cavorting on the rocks at a river 's edge`
100%|██████████| 100/100 [00:05<00:00, 17.69it/s]
Four people are cavorting on the rocks at a river 's edge
Best match index: 27 Best result: 0.39304018
Текст запроса: `Two young boys are squirting water at each other .` This image is unavailable in your country in compliance with local laws
Текст запроса: `Three teenagers drink Slurpees outside a convienience store .` This image is unavailable in your country in compliance with local laws
Текст запроса: `A bird flying over the water .`
100%|██████████| 100/100 [00:06<00:00, 14.57it/s]
A bird flying over the water .
Best match index: 27 Best result: 0.72441745
Текст запроса: `A tan dog runs through the brush .`
100%|██████████| 100/100 [00:07<00:00, 13.89it/s]
A tan dog runs through the brush .
Best match index: 86 Best result: 0.7637233
Текст запроса: `A girl in a purple shirt feeding ducks` This image is unavailable in your country in compliance with local laws
Текст запроса: `The man in the backpack looks to his left .`
100%|██████████| 100/100 [00:07<00:00, 13.05it/s]
The man in the backpack looks to his left .
Best match index: 92 Best result: 0.12445805
Текст запроса: `A boy jumps off a tan rock .` This image is unavailable in your country in compliance with local laws
Текст запроса: `A dog carries a large stick in its mouth over the grass .`
100%|██████████| 100/100 [00:06<00:00, 16.34it/s]
A dog carries a large stick in its mouth over the grass .
Best match index: 86 Best result: 0.45126152
Рекомендации:
Сделать обязательным условием, чтобы не только у оценок экспертов по сходству было одинаковое число голосов за картинку, но чтобы и число краудсорсеров проголосовавших за определенную картинку было хотя бы не меньше определенного фиксированного числа. Данна рекомендация поможет избежать аномальных значений в создании агрегированной метрики;
Также лучшем решением было бы внедрение для краудсорсинга такой же шкалы оценивания, как и у экспертов (значения 1, 2, 3, 4), а не просто соответствует/отличается. Таким образом финальная метрика смогла бы оказаться более точной и полезной для обучения новой модели;
Увеличить привлекаемых краудсорсеров, так как в имеющихся на настоящий момент данных число записей в оценках с краудсорса в два раза меньше, чем записей с оценками экспертов;
Создать метрику оценки сходства изображений, опираясь на мнение экспертов и краудсорсинга, чтобы она наиболее точно отражала меры сходства. Данную задачу можно поручитьинженерам данных, чтобы получилась наиболее выверенная оценка;
Вывод:
В рамках проекта был создан MVP для системы поиска изображений по фотографии.
В ходе работы для векторизации изображений использовалась предобученная на ImageNet архитектура ResNet-18, а для текстовых эмбеддингов - также предобученный DistilBert. Проанализировали и выбрали лучшую оценку схожести картинки и запроса, плюс подобрали метрику качества для обучения и теста моделей.
После объединения признаков и выделения таргетов (экспертные оценки схожести описания и картинок), было произведено исследование трех моделей для оценки схожести:
LinearRegression;Ridge;Lasso;Полносвязная нейронная сеть.Наилучший результат показала нейронная сеть, с которой и проводилось тестирование.
В рамках теста была создана функция, преобразующая тестовые запросы и картинки в эмбэдинги, после проверяющая соблюдение юридической стороны запросом, а после выводящая самую похожую на описание картинку с помощью обученной модели NN.
Наша модель полносвязной нейронной сети показала отличные результаты на валидационной выборке с результатом MAE = 0.06 и неплохие на тестовой выборке, однако имеют место недочёты. НС неплохо выделяет основные слова из текста и ищет их соответствия на картинках. Да, бывает она может прицепится только к какой-то одной отдельной детали из описания и выдать картинки связанные тематически, но в мере сходства она отразит низкий процент соответствия картинки и описания. Значит, наша модель всё-таки неплохо работает. И ее можно пробовать выпускать в продакшн.
Для более точных результатов необходимо применить рекомендации выше при подготовке данных, также улучшить показатели сети могло бы увеличение предоставленных данных как для тренировки, так и для тестирования.